﻿/*
 * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
 * See https://github.com/openiddict/openiddict-core for more information concerning
 * the license and the contributors participating to this project.
 */

using System.Collections.Immutable;
using System.ComponentModel;
using System.Diagnostics;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using OpenIddict.Extensions;
using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants;

namespace OpenIddict.Client.WebIntegration;

[EditorBrowsable(EditorBrowsableState.Never)]
public static partial class OpenIddictClientWebIntegrationHandlers
{
    public static ImmutableArray<OpenIddictClientHandlerDescriptor> DefaultHandlers { get; } =
    [
        /*
         * Authentication processing:
         */
        ValidateRedirectionRequestSignature.Descriptor,
        HandleNonStandardFrontchannelErrorResponse.Descriptor,
        ValidateNonStandardParameters.Descriptor,
        OverrideTokenEndpointClientAuthenticationMethod.Descriptor,
        OverrideTokenEndpoint.Descriptor,
        AttachNonStandardClientAssertionClaims.Descriptor,
        AttachAdditionalTokenRequestParameters.Descriptor,
        AttachTokenRequestNonStandardClientCredentials.Descriptor,
        AdjustRedirectUriInTokenRequest.Descriptor,
        OverrideValidatedBackchannelTokens.Descriptor,
        DisableBackchannelIdentityTokenNonceValidation.Descriptor,
        OverrideUserInfoRetrieval.Descriptor,
        OverrideUserInfoValidation.Descriptor,
        OverrideUserInfoEndpoint.Descriptor,
        AttachAdditionalUserInfoRequestParameters.Descriptor,
        PopulateUserInfoTokenPrincipalFromTokenResponse.Descriptor,
        MapCustomWebServicesFederationClaims.Descriptor,

        /*
         * Challenge processing:
         */
        ValidateChallengeProperties.Descriptor,
        OverrideAuthorizationEndpoint.Descriptor,
        OverrideResponseMode.Descriptor,
        FormatNonStandardScopeParameter.Descriptor,
        IncludeStateParameterInRedirectUri.Descriptor,
        AttachAdditionalChallengeParameters.Descriptor,

        /*
         * Revocation processing:
         */
        OverrideRevocationEndpointClientAuthenticationMethod.Descriptor,
        AttachNonStandardRevocationClientAssertionClaims.Descriptor,
        AttachRevocationRequestNonStandardClientCredentials.Descriptor,

        .. Authentication.DefaultHandlers,
        .. Device.DefaultHandlers,
        .. Discovery.DefaultHandlers,
        .. Exchange.DefaultHandlers,
        .. Introspection.DefaultHandlers,
        .. Protection.DefaultHandlers,
        .. Revocation.DefaultHandlers,
        .. UserInfo.DefaultHandlers
    ];

    /// <summary>
    /// Contains the logic responsible for validating the signature or message authentication
    /// code attached to the redirection request for the providers that require it.
    /// </summary>
    public sealed class ValidateRedirectionRequestSignature : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireRedirectionRequest>()
                .UseSingletonHandler<ValidateRedirectionRequestSignature>()
                .SetOrder(ValidateIssuerParameter.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // Shopify returns custom/non-standard parameters like the name of the shop for which the
            // installation request was initiated. To prevent these parameters from being tampered with,
            // a "hmac" parameter is added by Shopify alongside a "timestamp" parameter containing the
            // UNIX-formatted date at which the authorization response was generated. While this doesn't
            // by itself protect against replayed HMACs, the HMAC always includes the "state" parameter,
            // which is itself protected against replay attacks as state tokens are automatically marked
            // as redeemed by OpenIddict when they are returned to the redirection endpoint.
            //
            // For more information, see
            // https://shopify.dev/docs/apps/auth/oauth/getting-started#step-2-verify-the-installation-request.
            if (context.Registration.ProviderType is ProviderTypes.Shopify &&
                !string.IsNullOrEmpty(context.Registration.ClientSecret))
            {
                var signature = (string?) context.Request["hmac"];
                if (string.IsNullOrEmpty(signature))
                {
                    context.Reject(
                        error: Errors.InvalidRequest,
                        description: SR.FormatID2029("hmac"),
                        uri: SR.FormatID8000(SR.ID2029));

                    return default;
                }

                var builder = new StringBuilder();

                // Note: the "hmac" parameter MUST be ignored and the remaining parameters MUST be sorted alphabetically.
                //
                // See https://shopify.dev/docs/apps/auth/oauth/getting-started#remove-the-hmac-parameter-from-the-query-string
                // for more information.
                foreach (var (name, value) in
                    from parameter in OpenIddictHelpers.ParseQuery(context.RequestUri!.Query)
                    where !string.IsNullOrEmpty(parameter.Key)
                    where !string.Equals(parameter.Key, "hmac", StringComparison.Ordinal)
                    orderby parameter.Key ascending
                    from value in parameter.Value
                    select (Name: parameter.Key, Value: value))
                {
                    if (builder.Length is > 0)
                    {
                        builder.Append('&');
                    }

                    builder.Append(Uri.EscapeDataString(name));

                    if (!string.IsNullOrEmpty(value))
                    {
                        builder.Append('=');
                        builder.Append(Uri.EscapeDataString(value));
                    }
                }

                // Compare the received HMAC (represented as an hexadecimal string) and the HMAC computed
                // locally from the concatenated query string: if the two don't match, return an error.
                //
                // Note: to prevent timing attacks, a time-constant comparer is always used.
                try
                {
                    if (!OpenIddictHelpers.FixedTimeEquals(
                        left : OpenIddictHelpers.ConvertFromHexadecimalString(signature),
                        right: OpenIddictHelpers.ComputeSha256MessageAuthenticationCode(
                            key : Encoding.UTF8.GetBytes(context.Registration.ClientSecret),
                            data: Encoding.UTF8.GetBytes(builder.ToString()))))
                    {
                        context.Reject(
                            error: Errors.InvalidRequest,
                            description: SR.FormatID2052("hmac"),
                            uri: SR.FormatID8000(SR.ID2052));

                        return default;
                    }
                }

                catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception))
                {
                    context.Reject(
                        error: Errors.InvalidRequest,
                        description: SR.FormatID2052("hmac"),
                        uri: SR.FormatID8000(SR.ID2052));

                    return default;
                }
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for handling non-standard
    /// authorization errors for the providers that require it.
    /// </summary>
    public sealed class HandleNonStandardFrontchannelErrorResponse : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireRedirectionRequest>()
                .UseSingletonHandler<HandleNonStandardFrontchannelErrorResponse>()
                .SetOrder(HandleFrontchannelErrorResponse.Descriptor.Order - 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // Note: some providers are known to return non-standard errors.
            // To normalize the set of errors handled by the OpenIddict client,
            // the non-standard errors are mapped to their standard equivalent.
            //
            // Errors that are not handled here will be automatically handled
            // by the standard handler present in the core OpenIddict client.

            if (context.Registration.ProviderType is ProviderTypes.Bitly &&
                (bool?) context.Request["invalid"] is true)
            {
                // Note: Bitly uses custom "invalid" and "reason" parameters instead of
                // the standard "error" parameter defined by the OAuth 2.0 specification.
                var error = (string?) context.Request["reason"];

                context.Reject(
                    error: error switch
                    {
                        "deny" => Errors.AccessDenied,
                           _   => Errors.ServerError
                    },
                    description: error switch
                    {
                        "deny" => SR.GetResourceString(SR.ID2149),
                           _   => SR.GetResourceString(SR.ID2152)
                    },
                    uri: error switch
                    {
                        "deny" => SR.FormatID8000(SR.ID2149),
                           _   => SR.FormatID8000(SR.ID2152)
                    });

                return default;
            }

            else if (context.Registration.ProviderType is ProviderTypes.Deezer)
            {
                // Note: Deezer uses a custom "error_reason" parameter instead of the
                // standard "error" parameter defined by the OAuth 2.0 specification.
                //
                // See https://developers.deezer.com/api/oauth for more information.
                var error = (string?) context.Request["error_reason"];
                if (!string.IsNullOrEmpty(error))
                {
                    context.Reject(
                        error: error switch
                        {
                            "user_denied" => Errors.AccessDenied,
                                  _       => Errors.ServerError
                        },
                        description: error switch
                        {
                            "user_denied" => SR.GetResourceString(SR.ID2149),
                                  _       => SR.GetResourceString(SR.ID2152)
                        },
                        uri: error switch
                        {
                            "user_denied" => SR.FormatID8000(SR.ID2149),
                                  _       => SR.FormatID8000(SR.ID2152)
                        });

                    return default;
                }
            }

            else if (context.Registration.ProviderType is ProviderTypes.Huawei)
            {
                var error = (long?) context.Request[Parameters.Error];
                if (error is not null)
                {
                    context.Reject(
                        error: error switch
                        {
                            1107 => Errors.AccessDenied,
                              _  => Errors.ServerError
                        },
                        description: error switch
                        {
                            1107 => SR.GetResourceString(SR.ID2149),
                              _  => SR.GetResourceString(SR.ID2152)
                        },
                        uri: error switch
                        {
                            1107 => SR.FormatID8000(SR.ID2149),
                              _  => SR.FormatID8000(SR.ID2152)
                        });

                    return default;
                }
            }

            else if (context.Registration.ProviderType is ProviderTypes.LinkedIn)
            {
                var error = (string?) context.Request[Parameters.Error];
                if (string.Equals(error, "user_cancelled_authorize", StringComparison.Ordinal) ||
                    string.Equals(error, "user_cancelled_login", StringComparison.Ordinal))
                {
                    context.Reject(
                        error: Errors.AccessDenied,
                        description: SR.GetResourceString(SR.ID2149),
                        uri: SR.FormatID8000(SR.ID2149));

                    return default;
                }
            }

            else if (context.Registration.ProviderType is ProviderTypes.Mixcloud or ProviderTypes.Pipedrive)
            {
                var error = (string?) context.Request[Parameters.Error];
                if (string.Equals(error, "user_denied", StringComparison.Ordinal))
                {
                    context.Reject(
                        error: Errors.AccessDenied,
                        description: SR.GetResourceString(SR.ID2149),
                        uri: SR.FormatID8000(SR.ID2149));

                    return default;
                }
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for validating custom parameters for the providers that require it.
    /// </summary>
    public sealed class ValidateNonStandardParameters : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireRedirectionRequest>()
                .AddFilter<RequireStateTokenPrincipal>()
                .AddFilter<RequireStateTokenValidated>()
                .UseSingletonHandler<ValidateNonStandardParameters>()
                .SetOrder(ResolveGrantTypeAndResponseTypeFromStateToken.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.StateTokenPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));

            if (context.Registration.ProviderType is ProviderTypes.Shopify)
            {
                var domain = (string?) context.Request["shop"];
                if (string.IsNullOrEmpty(domain))
                {
                    context.Reject(
                        error: Errors.InvalidRequest,
                        description: SR.FormatID2029("shop"),
                        uri: SR.FormatID8000(SR.ID2029));

                    return default;
                }

                // Resolve the shop name from the authentication properties.
                if (!context.Properties.TryGetValue(Shopify.Properties.ShopName, out string? name) ||
                    string.IsNullOrEmpty(name))
                {
                    throw new InvalidOperationException(SR.GetResourceString(SR.ID0412));
                }

                // Note: the shop domain extracted from the redirection request is not used by OpenIddict (that stores
                // the shop name in the state token), but it can be resolved and used by the developers in their own code.
                //
                // To ensure the value is correct, it is compared to the shop name stored in the state token: if
                // the two don't match, the request is automatically rejected to prevent a potential mixup attack.
                if (!string.Equals(domain, $"{name}.myshopify.com", StringComparison.Ordinal))
                {
                    context.Reject(
                        error: Errors.InvalidRequest,
                        description: SR.FormatID2052("shop"),
                        uri: SR.FormatID8000(SR.ID2052));

                    return default;
                }
            }

            // VK ID uses a non-standard "device_id" parameter in authorization responses.
            else if (context.Registration.ProviderType is ProviderTypes.VkId)
            {
                var identifier = (string?) context.Request["device_id"];
                if (string.IsNullOrEmpty(identifier))
                {
                    context.Reject(
                        error: Errors.InvalidRequest,
                        description: SR.FormatID2029("device_id"),
                        uri: SR.FormatID8000(SR.ID2029));

                    return default;
                }

                // Store the device identifier as an authentication property
                // so it can be resolved later to make refresh token requests.
                context.Properties[VkId.Properties.DeviceId] = identifier;
            }

            // Zoho returns the region of the authenticated user as a non-standard "location" parameter
            // that must be used to compute the address of the token and userinfo endpoints.
            else if (context.Registration.ProviderType is ProviderTypes.Zoho)
            {
                var location = (string?) context.Request["location"];
                if (string.IsNullOrEmpty(location))
                {
                    context.Reject(
                        error: Errors.InvalidRequest,
                        description: SR.FormatID2029("location"),
                        uri: SR.FormatID8000(SR.ID2029));

                    return default;
                }

                // Ensure the specified location corresponds to well-known region.
                if (location.ToUpperInvariant() is not ("AU" or "CA" or "EU" or "IN" or "JP" or "SA" or "UK" or "US"))
                {
                    context.Reject(
                        error: Errors.InvalidRequest,
                        description: SR.FormatID2052("location"),
                        uri: SR.FormatID8000(SR.ID2052));

                    return default;
                }

                // Store the validated location as an authentication property
                // so it can be resolved later to determine the user region.
                context.Properties[Zoho.Properties.Location] = location;
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the negotiated token
    /// endpoint client authentication method for the providers that require it.
    /// </summary>
    public sealed class OverrideTokenEndpointClientAuthenticationMethod : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireTokenRequest>()
                .UseSingletonHandler<OverrideTokenEndpointClientAuthenticationMethod>()
                .SetOrder(AttachTokenEndpointClientAuthenticationMethod.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.TokenEndpointClientAuthenticationMethod = context.Registration.ProviderType switch
            {
                // Note: Bitly only supports using "client_secret_post" for the authorization code
                // grant but not for the resource owner password credentials grant, that requires
                // using the "client_secret_basic" method instead.
                ProviderTypes.Bitly when context.GrantType is GrantTypes.Password
                    => ClientAuthenticationMethods.ClientSecretBasic,

                // Note: Reddit requires using basic authentication to flow the client_id for all types
                // of client applications, even when there's no client_secret assigned or attached.
                ProviderTypes.Reddit => ClientAuthenticationMethods.ClientSecretBasic,

                _ => context.TokenEndpointClientAuthenticationMethod
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the address
    /// of the token endpoint for the providers that require it.
    /// </summary>
    public sealed class OverrideTokenEndpoint : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .UseSingletonHandler<OverrideTokenEndpoint>()
                .SetOrder(ResolveTokenEndpoint.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.TokenEndpoint = context.Registration.ProviderType switch
            {
                // Shopify is a multitenant provider that requires setting the token endpoint dynamically
                // based on the shop name stored in the authentication properties set during the challenge.
                //
                // For more information, see
                // https://shopify.dev/docs/apps/auth/oauth/getting-started#step-5-get-an-access-token.
                ProviderTypes.Shopify when context.GrantType is GrantTypes.AuthorizationCode &&
                    context.Properties[Shopify.Properties.ShopName] is var name =>
                    new Uri($"https://{name}.myshopify.com/admin/oauth/access_token", UriKind.Absolute),

                // Trovo uses a different token endpoint for the refresh token grant.
                //
                // For more information, see
                // https://developer.trovo.live/docs/APIs.html#_4-3-refresh-access-token.
                ProviderTypes.Trovo when context.GrantType is GrantTypes.RefreshToken
                    => new Uri("https://open-api.trovo.live/openplatform/refreshtoken", UriKind.Absolute),

                // Zoho requires using a region-specific token endpoint determined using
                // the "location" parameter returned from the authorization endpoint.
                //
                // For more information, see
                // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html.
                ProviderTypes.Zoho when context.GrantType is GrantTypes.AuthorizationCode
                    => ((string?) context.Request?["location"])?.ToUpperInvariant() switch
                    {
                        "AU" => new Uri("https://accounts.zoho.com.au/oauth/v2/token", UriKind.Absolute),
                        "CA" => new Uri("https://accounts.zohocloud.ca/oauth/v2/token", UriKind.Absolute),
                        "EU" => new Uri("https://accounts.zoho.eu/oauth/v2/token", UriKind.Absolute),
                        "IN" => new Uri("https://accounts.zoho.in/oauth/v2/token", UriKind.Absolute),
                        "JP" => new Uri("https://accounts.zoho.jp/oauth/v2/token", UriKind.Absolute),
                        "SA" => new Uri("https://accounts.zoho.sa/oauth/v2/token", UriKind.Absolute),
                        "UK" => new Uri("https://accounts.zoho.uk/oauth/v2/token", UriKind.Absolute),
                        "US" => new Uri("https://accounts.zoho.com/oauth/v2/token", UriKind.Absolute),

                        _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0477))
                    },

                ProviderTypes.Zoho when context.GrantType is GrantTypes.RefreshToken
                    => !context.Properties.TryGetValue(Zoho.Properties.Location, out string? location) ||
                        string.IsNullOrEmpty(location) ? throw new InvalidOperationException(SR.GetResourceString(SR.ID0451)) :
                        location?.ToUpperInvariant() switch
                        {
                            "AU" => new Uri("https://accounts.zoho.com.au/oauth/v2/token", UriKind.Absolute),
                            "CA" => new Uri("https://accounts.zohocloud.ca/oauth/v2/token", UriKind.Absolute),
                            "EU" => new Uri("https://accounts.zoho.eu/oauth/v2/token", UriKind.Absolute),
                            "IN" => new Uri("https://accounts.zoho.in/oauth/v2/token", UriKind.Absolute),
                            "JP" => new Uri("https://accounts.zoho.jp/oauth/v2/token", UriKind.Absolute),
                            "SA" => new Uri("https://accounts.zoho.sa/oauth/v2/token", UriKind.Absolute),
                            "UK" => new Uri("https://accounts.zoho.uk/oauth/v2/token", UriKind.Absolute),
                            "US" => new Uri("https://accounts.zoho.com/oauth/v2/token", UriKind.Absolute),

                            _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0477))
                        },

                _ => context.TokenEndpoint
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for adding non-standard claims to the client
    /// assertions used for the token endpoint for the providers that require it.
    /// </summary>
    public sealed class AttachNonStandardClientAssertionClaims : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireClientAssertionGenerated>()
                .UseSingletonHandler<AttachNonStandardClientAssertionClaims>()
                .SetOrder(PrepareClientAssertionPrincipal.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.ClientAssertionPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));

            // For client assertions to be considered valid by the Apple ID authentication service,
            // the team identifier associated with the developer account MUST be used as the issuer
            // and the static "https://appleid.apple.com" URL MUST be used as the token audience.
            //
            // For more information about the custom client authentication method implemented by Apple,
            // see https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens.
            if (context.Registration.ProviderType is ProviderTypes.Apple)
            {
                var settings = context.Registration.GetAppleSettings();

                context.ClientAssertionPrincipal.SetClaim(Claims.Private.Issuer, settings.TeamId);
                context.ClientAssertionPrincipal.SetAudiences("https://appleid.apple.com");
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for attaching additional parameters
    /// to the token request for the providers that require it.
    /// </summary>
    public sealed class AttachAdditionalTokenRequestParameters : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireTokenRequest>()
                .UseSingletonHandler<AttachAdditionalTokenRequestParameters>()
                .SetOrder(AttachTokenRequestParameters.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.TokenRequest is not null, SR.GetResourceString(SR.ID4008));

            // When using the device authorization code grant, Amazon requires sending a non-standard
            // "user_code" node containing the user code returned by the device authorization endpoint.
            //
            // As the user code is not a standard concept for token requests, the user code is expected
            // to be attached as an Amazon-specific authentication property. If no user code or an empty
            // value was attached, an exception is thrown to abort the authentication process.
            if (context.GrantType is GrantTypes.DeviceCode &&
                context.Registration.ProviderType is ProviderTypes.Amazon)
            {
                if (!context.Properties.TryGetValue(Amazon.Properties.UserCode, out string? code) ||
                    string.IsNullOrEmpty(code))
                {
                    throw new InvalidOperationException(SR.GetResourceString(SR.ID0427));
                }

                context.TokenRequest.UserCode = code;
            }

            // VK ID requires attaching a non-standard "device_id" parameter to all token requests.
            //
            // This parameter is either resolved from the authorization response (for the authorization
            // code or hybrid grants) or manually provided by the application for other grant types.
            else if (context.Registration.ProviderType is ProviderTypes.VkId)
            {
                context.TokenRequest["device_id"] = context.GrantType switch
                {
                    GrantTypes.AuthorizationCode or GrantTypes.Implicit => context.Request["device_id"],

                    _ when context.Properties.TryGetValue(VkId.Properties.DeviceId, out string? identifier) &&
                        !string.IsNullOrEmpty(identifier) => identifier,

                    _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0467))
                };
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for attaching custom client credentials
    /// parameters to the token request for the providers that require it.
    /// </summary>
    public sealed class AttachTokenRequestNonStandardClientCredentials : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireTokenRequest>()
                .UseSingletonHandler<AttachTokenRequestNonStandardClientCredentials>()
                .SetOrder(AttachTokenRequestClientCredentials.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.TokenRequest is not null, SR.GetResourceString(SR.ID4008));

            // Apple implements a non-standard client authentication method for its endpoints that
            // is inspired by the standard private_key_jwt method but doesn't use the standard
            // client_assertion/client_assertion_type parameters. Instead, the client assertion
            // must be sent as a "dynamic" client secret using client_secret_post. Since the logic
            // is the same as private_key_jwt, the configuration is amended to assume Apple supports
            // private_key_jwt and an event handler is responsible for populating the client_secret
            // parameter using the client assertion once it has been generated by OpenIddict.
            if (context.Registration.ProviderType is ProviderTypes.Apple)
            {
                context.TokenRequest.ClientSecret = context.TokenRequest.ClientAssertion;
                context.TokenRequest.ClientAssertion = null;
                context.TokenRequest.ClientAssertionType = null;
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the redirect_uri
    /// attached to the token request for the providers that require it.
    /// </summary>
    public sealed class AdjustRedirectUriInTokenRequest : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireTokenRequest>()
                .UseSingletonHandler<AdjustRedirectUriInTokenRequest>()
                .SetOrder(AttachTokenRequestClientCredentials.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.TokenRequest is not null, SR.GetResourceString(SR.ID4008));

            if (context.TokenRequest.RedirectUri is null)
            {
                return default;
            }

            // Note: some providers don't support the "state" parameter, don't flow
            // it correctly or don't include it in errored authorization responses.
            //
            // Since OpenIddict requires flowing the state token in every circumstance
            // (for security reasons), the state token is appended to the "redirect_uri"
            // instead of being sent as a standard OAuth 2.0 authorization request parameter.
            //
            // Note: for token requests to use the actual redirect_uri that was sent as part
            // of the authorization requests, the value persisted in the state token principal
            // MUST be replaced to include the state token received by the redirection endpoint.

            context.TokenRequest.RedirectUri = context.Registration.ProviderType switch
            {
                ProviderTypes.Deezer or
                ProviderTypes.Huawei or
                ProviderTypes.Mixcloud => OpenIddictHelpers.AddQueryStringParameter(
                    uri  : new Uri(context.TokenRequest.RedirectUri, UriKind.Absolute),
                    name : Parameters.State,
                    value: context.StateToken).AbsoluteUri,

                _ => context.TokenRequest.RedirectUri
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the set
    /// of required tokens for the providers that require it.
    /// </summary>
    public sealed class OverrideValidatedBackchannelTokens : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .UseSingletonHandler<OverrideValidatedBackchannelTokens>()
                .SetOrder(EvaluateValidatedBackchannelTokens.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            (context.ExtractBackchannelIdentityToken,
             context.RequireBackchannelIdentityToken,
             context.ValidateBackchannelIdentityToken) = context.Registration.ProviderType switch
            {
                // Clever claims the OpenID Connect flavor of the code flow is supported but
                // their implementation doesn't always return an id_token from the token endpoint.
                ProviderTypes.Clever => (true, false, true),

                // While PayPal claims the OpenID Connect flavor of the code flow is supported,
                // their implementation doesn't return an id_token from the token endpoint.
                ProviderTypes.PayPal => (false, false, false),
                
                // NetSuite does not return an id_token when using the refresh_token grant type.
                // Additionally, the at_hash inside their id_token is not a valid hash of the
                // access token, but is instead a copy of the RS256 signature within the access token.
                ProviderTypes.NetSuite => (true, false, false),

                _ => (context.ExtractBackchannelIdentityToken,
                      context.RequireBackchannelIdentityToken,
                      context.ValidateBackchannelIdentityToken)
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for disabling the backchannel
    /// identity token nonce validation for the providers that require it.
    /// </summary>
    public sealed class DisableBackchannelIdentityTokenNonceValidation : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .UseSingletonHandler<DisableBackchannelIdentityTokenNonceValidation>()
                .SetOrder(ValidateBackchannelIdentityTokenNonce.Descriptor.Order - 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // Note: despite implementing OpenID Connect, some providers are known to implement the
            // specification incorrectly and either don't support the "nonce" authorization request
            // parameter, don't include it in the issued identity tokens or flow an unexpected value.
            //
            // Despite being an important security feature, nonce validation is explicitly disabled
            // for the providers that are known to cause errors when nonce validation is enforced.

            context.DisableBackchannelIdentityTokenNonceValidation = context.Registration.ProviderType switch
            {
                // These providers don't include the nonce in their identity tokens:
                ProviderTypes.Asana    or ProviderTypes.DocuSign         or
                ProviderTypes.Dropbox  or ProviderTypes.FaceIt           or
                ProviderTypes.LinkedIn or ProviderTypes.QuickBooksOnline or
                ProviderTypes.WorldId => true,

                _ => context.DisableBackchannelIdentityTokenNonceValidation
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the userinfo retrieval for the providers that require it.
    /// </summary>
    public sealed class OverrideUserInfoRetrieval : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .UseSingletonHandler<OverrideUserInfoRetrieval>()
                .SetOrder(EvaluateUserInfoRequest.Descriptor.Order + 250)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.SendUserInfoRequest = context.Registration.ProviderType switch
            {
                // Note: ADFS has severe restrictions affecting the ability to access the userinfo endpoint
                // (e.g the "resource" parameter MUST be null or the "urn:microsoft:userinfo" value MUST be
                // used, which prevents specifying any other resource as only one value is allowed by ADFS).
                //
                // Since the userinfo endpoint returns very limited information anyway,
                // userinfo retrieval is always disabled for the ADFS provider.
                ProviderTypes.ActiveDirectoryFederationServices => false,

                // Note: these providers don't have a static userinfo endpoint attached to their configuration
                // so OpenIddict doesn't, by default, send a userinfo request. Since a dynamic endpoint is later
                // computed and attached to the context, the default value MUST be overridden to send a request.
                ProviderTypes.Dailymotion or ProviderTypes.HubSpot or
                ProviderTypes.SuperOffice or ProviderTypes.Zoho
                    when context.GrantType is GrantTypes.AuthorizationCode or GrantTypes.DeviceCode or
                                              GrantTypes.Implicit          or GrantTypes.Password   or
                                              GrantTypes.RefreshToken      or
                           // Apply the same logic for custom grant types.
                           (not null and not (GrantTypes.AuthorizationCode or GrantTypes.ClientCredentials or
                                              GrantTypes.DeviceCode        or GrantTypes.Implicit          or
                                              GrantTypes.Password          or GrantTypes.RefreshToken)) &&
                        !context.DisableUserInfoRetrieval && (!string.IsNullOrEmpty(context.BackchannelAccessToken) ||
                                                              !string.IsNullOrEmpty(context.FrontchannelAccessToken)) => true,

                // Note: the frontchannel or backchannel access tokens returned by Microsoft Entra ID
                // when a Xbox scope is requested cannot be used with the userinfo endpoint as they use
                // a legacy format that is not supported by the Microsoft Entra ID userinfo implementation.
                //
                // To work around this limitation, userinfo retrieval is disabled when a Xbox scope is requested.
                ProviderTypes.Microsoft => context.GrantType switch
                {
                    GrantTypes.AuthorizationCode or GrantTypes.Implicit when
                        context.StateTokenPrincipal is ClaimsPrincipal principal &&
                        principal.HasClaim(static claim =>
                            claim.Type is Claims.Private.Scope &&
                            claim.Value.StartsWith("XboxLive.", StringComparison.OrdinalIgnoreCase))
                        => false,

                    GrantTypes.DeviceCode or GrantTypes.RefreshToken when
                        context.Scopes.Any(static scope => scope.StartsWith("XboxLive.", StringComparison.OrdinalIgnoreCase))
                        => false,

                    _ => context.SendUserInfoRequest
                },

                // Note: some providers don't allow querying the userinfo endpoint when the "openid" scope
                // is not requested or granted. To work around that, userinfo is disabled when the "openid"
                // scope wasn't requested during the initial authorization request or during the token request.
                ProviderTypes.Okta => context.GrantType switch
                {
                    GrantTypes.AuthorizationCode or GrantTypes.Implicit when
                        context.StateTokenPrincipal is ClaimsPrincipal principal && !principal.HasScope(Scopes.OpenId)
                        => false,

                    GrantTypes.DeviceCode or GrantTypes.RefreshToken when !context.Scopes.Contains(Scopes.OpenId)
                        => false,

                    _ => context.SendUserInfoRequest
                },

                _ => context.SendUserInfoRequest
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the userinfo validation for the providers that require it.
    /// </summary>
    public sealed class OverrideUserInfoValidation : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .UseSingletonHandler<OverrideUserInfoValidation>()
                .SetOrder(OverrideUserInfoRetrieval.Descriptor.Order + 250)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // Note: despite implementing OpenID Connect, some providers are known to implement completely custom
            // userinfo endpoints or semi-standard endpoints that don't fully conform to the core specification.
            //
            // To ensure OpenIddict can be used with these providers, validation is disabled when necessary.

            context.DisableUserInfoValidation = context.Registration.ProviderType switch
            {
                // SuperOffice doesn't offer a standard OpenID Connect userinfo endpoint.
                ProviderTypes.SuperOffice => true,

                _ => context.DisableUserInfoValidation
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the address
    /// of the userinfo endpoint for the providers that require it.
    /// </summary>
    public sealed class OverrideUserInfoEndpoint : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireUserInfoRequest>()
                .UseSingletonHandler<OverrideUserInfoEndpoint>()
                .SetOrder(ResolveUserInfoEndpoint.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.UserInfoEndpoint = context.Registration.ProviderType switch
            {
                // Dailymotion's userinfo endpoint requires sending the user identifier in the URI path.
                ProviderTypes.Dailymotion when (string?) context.TokenResponse?["uid"] is string identifier
                    => OpenIddictHelpers.CreateAbsoluteUri(
                        left : new Uri("https://api.dailymotion.com/user", UriKind.Absolute),
                        right: new Uri(identifier, UriKind.Relative)),

                // HubSpot doesn't have a static userinfo endpoint but allows retrieving basic information
                // by using an access token info endpoint that requires sending the token in the URI path.
                ProviderTypes.HubSpot when
                    (context.BackchannelAccessToken ?? context.FrontchannelAccessToken) is { Length: > 0 } token
                    => OpenIddictHelpers.CreateAbsoluteUri(
                        left : new Uri("https://api.hubapi.com/oauth/v1/access-tokens", UriKind.Absolute),
                        right: new Uri(token, UriKind.Relative)),

                // SuperOffice doesn't expose a static OpenID Connect userinfo endpoint but offers an API whose
                // absolute URI needs to be computed based on a special claim returned in the identity token.
                ProviderTypes.SuperOffice when
                    (context.BackchannelIdentityTokenPrincipal ?? // Always prefer the backchannel identity token when available.
                     context.FrontchannelIdentityTokenPrincipal) is ClaimsPrincipal principal &&
                    Uri.TryCreate(principal.GetClaim("http://schemes.superoffice.net/identity/webapi_url"), UriKind.Absolute, out Uri? uri)
                    => OpenIddictHelpers.CreateAbsoluteUri(uri, new Uri("v1/user/currentPrincipal", UriKind.Relative)),

                // Zoho requires using a region-specific userinfo endpoint determined using
                // the "location" parameter returned from the authorization endpoint.
                //
                // For more information, see
                // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html.
                ProviderTypes.Zoho when context.GrantType is GrantTypes.AuthorizationCode
                    => ((string?) context.Request?["location"])?.ToUpperInvariant() switch
                    {
                        "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute),
                        "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute),
                        "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute),
                        "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute),
                        "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute),
                        "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute),
                        "UK" => new Uri("https://accounts.zoho.uk/oauth/user/info", UriKind.Absolute),
                        "US" => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute),

                        _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0477))
                    },

                ProviderTypes.Zoho when context.GrantType is GrantTypes.RefreshToken
                    => !context.Properties.TryGetValue(Zoho.Properties.Location, out string? location) ||
                        string.IsNullOrEmpty(location) ? throw new InvalidOperationException(SR.GetResourceString(SR.ID0451)) :
                        location?.ToUpperInvariant() switch
                        {
                            "AU" => new Uri("https://accounts.zoho.com.au/oauth/user/info", UriKind.Absolute),
                            "CA" => new Uri("https://accounts.zohocloud.ca/oauth/user/info", UriKind.Absolute),
                            "EU" => new Uri("https://accounts.zoho.eu/oauth/user/info", UriKind.Absolute),
                            "IN" => new Uri("https://accounts.zoho.in/oauth/user/info", UriKind.Absolute),
                            "JP" => new Uri("https://accounts.zoho.jp/oauth/user/info", UriKind.Absolute),
                            "SA" => new Uri("https://accounts.zoho.sa/oauth/user/info", UriKind.Absolute),
                            "UK" => new Uri("https://accounts.zoho.uk/oauth/user/info", UriKind.Absolute),
                            "US" => new Uri("https://accounts.zoho.com/oauth/user/info", UriKind.Absolute),

                            _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0477))
                        },

                _ => context.UserInfoEndpoint
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for attaching additional parameters
    /// to the userinfo request for the providers that require it.
    /// </summary>
    public sealed class AttachAdditionalUserInfoRequestParameters : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireUserInfoRequest>()
                .UseSingletonHandler<AttachAdditionalUserInfoRequestParameters>()
                .SetOrder(AttachUserInfoRequestParameters.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.UserInfoRequest is not null, SR.GetResourceString(SR.ID4008));

            // Dailymotion limits the number of fields returned by the userinfo endpoint
            // but allows returning additional information using special parameters that
            // determine what fields will be returned as part of the userinfo response.
            if (context.Registration.ProviderType is ProviderTypes.Dailymotion)
            {
                var settings = context.Registration.GetDailymotionSettings();

                context.UserInfoRequest["fields"] = string.Join(",", settings.UserFields);
            }

            // Disqus requires sending the client identifier (called "public
            // API key" in the documentation) as part of the userinfo request.
            else if (context.Registration.ProviderType is ProviderTypes.Disqus)
            {
                context.UserInfoRequest["api_key"] = context.Registration.ClientId;
            }

            // Facebook limits the number of fields returned by the userinfo endpoint
            // but allows returning additional information using special parameters that
            // determine what fields will be returned as part of the userinfo response.
            else if (context.Registration.ProviderType is ProviderTypes.Facebook)
            {
                var settings = context.Registration.GetFacebookSettings();

                context.UserInfoRequest["fields"] = string.Join(",", settings.Fields);
            }

            // Linear's userinfo endpoint is a GraphQL implementation that requires
            // sending a proper "query" parameter containing the requested user details.
            else if (context.Registration.ProviderType is ProviderTypes.Linear)
            {
                var settings = context.Registration.GetLinearSettings();

                context.UserInfoRequest["query"] = $"query {{ viewer {{ {string.Join(" ", settings.UserFields)} }} }}";
            }

            // Meetup's userinfo endpoint is a GraphQL implementation that requires
            // sending a proper "query" parameter containing the requested user details.
            else if (context.Registration.ProviderType is ProviderTypes.Meetup)
            {
                var settings = context.Registration.GetMeetupSettings();

                context.UserInfoRequest["query"] = $"query {{ self {{ {string.Join(" ", settings.UserFields)} }} }}";
            }

            // Patreon limits the number of fields returned by the userinfo endpoint
            // but allows returning additional information using special parameters that
            // determine what fields will be returned as part of the userinfo response.
            else if (context.Registration.ProviderType is ProviderTypes.Patreon)
            {
                var settings = context.Registration.GetPatreonSettings();

                context.UserInfoRequest["fields[user]"] = string.Join(",", settings.UserFields);
            }

            // StackOverflow requires sending an application key and a site parameter
            // containing the name of the site from which the user profile is retrieved.
            else if (context.Registration.ProviderType is ProviderTypes.StackExchange)
            {
                var settings = context.Registration.GetStackExchangeSettings();

                context.UserInfoRequest["key"] = settings.ApplicationKey;
                context.UserInfoRequest["site"] = settings.Site;
            }

            // SubscribeStar's userinfo endpoint is a GraphQL implementation that requires
            // sending a proper "query" parameter containing the requested user details.
            else if (context.Registration.ProviderType is ProviderTypes.SubscribeStar)
            {
                var settings = context.Registration.GetSubscribeStarSettings();

                context.UserInfoRequest["query"] = $"{{ user {{ {string.Join(", ", settings.UserFields)} }} }}";
            }

            // Todoist requires sending "sync_token" and "resource_types" parameters.
            else if (context.Registration.ProviderType is ProviderTypes.Todoist)
            {
                context.UserInfoRequest["sync_token"] = "*";
                context.UserInfoRequest["resource_types"] = "[\"user\"]";
            }

            // Trakt allows retrieving additional user details via the "extended" parameter.
            else if (context.Registration.ProviderType is ProviderTypes.Trakt)
            {
                context.UserInfoRequest["extended"] = "full";
            }

            // Twitter limits the number of fields returned by the userinfo endpoint
            // but allows returning additional information using special parameters that
            // determine what fields will be returned as part of the userinfo response.
            else if (context.Registration.ProviderType is ProviderTypes.Twitter)
            {
                var settings = context.Registration.GetTwitterSettings();

                context.UserInfoRequest["expansions"] = string.Join(",", settings.Expansions);
                context.UserInfoRequest["tweet.fields"] = string.Join(",", settings.TweetFields);
                context.UserInfoRequest["user.fields"] = string.Join(",", settings.UserFields);
            }

            // Weibo requires sending the user identifier as part of the userinfo request.
            else if (context.Registration.ProviderType is ProviderTypes.Weibo)
            {
                context.UserInfoRequest["uid"] = context.TokenResponse?["uid"];
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for creating a userinfo token principal from the custom
    /// parameters returned in the token response for the providers that require it.
    /// </summary>
    public sealed class PopulateUserInfoTokenPrincipalFromTokenResponse : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireTokenRequest>()
                .UseSingletonHandler<PopulateUserInfoTokenPrincipalFromTokenResponse>()
                .SetOrder(ValidateUserInfoToken.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
            Debug.Assert(context.TokenResponse is not null, SR.GetResourceString(SR.ID4007));

            // Don't overwrite the userinfo token principal if one was already set.
            if (context.UserInfoTokenPrincipal is not null)
            {
                return default;
            }

            // Some providers don't provide an OAuth 2.0/OpenID Connect userinfo endpoint but
            // return the user information using custom/non-standard token response parameters.
            // To work around that, this handler is responsible for extracting these parameters
            // from the token response and creating a userinfo token principal containing them.

            var parameters = context.Registration.ProviderType switch
            {
                // For Shopify, include all the parameters contained in the "associated_user" object.
                //
                // Note: the "associated_user" node is only available when using the online access mode.
                ProviderTypes.Shopify => context.TokenResponse["associated_user"]?.GetNamedParameters(),

                // For Strava, include all the parameters contained in the "athlete" object.
                //
                // Note: the "athlete" node is not returned for grant_type=refresh_token requests.
                ProviderTypes.Strava => context.TokenResponse["athlete"]?.GetNamedParameters(),

                // For Stripe, only include "livemode" and the parameters that are prefixed with "stripe_":
                ProviderTypes.StripeConnect =>
                    from parameter in context.TokenResponse.GetParameters()
                    where string.Equals(parameter.Key, "livemode", StringComparison.OrdinalIgnoreCase) ||
                        parameter.Key.StartsWith("stripe_", StringComparison.OrdinalIgnoreCase)
                    select parameter,

                _ => null
            };

            if (parameters is null)
            {
                return default;
            }

            var identity = new ClaimsIdentity(
                context.Registration.TokenValidationParameters.AuthenticationType,
                context.Registration.TokenValidationParameters.NameClaimType,
                context.Registration.TokenValidationParameters.RoleClaimType);

            // Resolve the issuer that will be attached to the claims created by this handler.
            var issuer = context.Registration.ClaimsIssuer ??
                         context.Registration.ProviderName ??
                         context.Registration.Issuer.AbsoluteUri;

            foreach (var parameter in parameters)
            {
                // Note: in the typical case, the response parameters should be deserialized from a
                // JSON response and thus natively stored as System.Text.Json.JsonElement instances.
                //
                // In the rare cases where the underlying value wouldn't be a JsonElement instance
                // (e.g when custom parameters are manually added to the response), the static
                // conversion operator would take care of converting the underlying value to a
                // JsonElement instance using the same value type as the original parameter value.
                switch ((JsonElement) parameter.Value)
                {
                    // Top-level claims represented as arrays are split and mapped to multiple CLR claims
                    // to match the logic implemented by IdentityModel for JWT token deserialization.
                    case { ValueKind: JsonValueKind.Array } value:
                        identity.AddClaims(parameter.Key, value, issuer);
                        break;

                    case { ValueKind: _ } value:
                        identity.AddClaim(parameter.Key, value, issuer);
                        break;
                }
            }

            context.UserInfoTokenPrincipal = new ClaimsPrincipal(identity);

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for mapping select custom claims to
    /// their WS-Federation equivalent for the providers that require it.
    /// </summary>
    public sealed class MapCustomWebServicesFederationClaims : IOpenIddictClientHandler<ProcessAuthenticationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
                .AddFilter<RequireWebServicesFederationClaimMappingEnabled>()
                .UseSingletonHandler<MapCustomWebServicesFederationClaims>()
                .SetOrder(MapStandardWebServicesFederationClaims.Descriptor.Order + 1_000)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessAuthenticationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));

            // As an OpenID Connect framework, the OpenIddict client mostly uses the claim set defined by the OpenID
            // Connect core specification (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims).
            // While these claims can be easily accessed using their standard OIDC name, many components still use
            // the Web Services Federation claims exposed by the BCL ClaimTypes class, sometimes without allowing
            // to use different claim types (e.g ASP.NET Core Identity hardcodes ClaimTypes.NameIdentifier in a few
            // places, like the GetUserId() extension). To reduce the difficulty of using the OpenIddict client with
            // these components relying on WS-Federation-style claims, these claims are mapped from the custom,
            // provider-specific parameters (either from the userinfo response or from the token rensponse).
            //
            // Note: a similar event handler exists in OpenIddict.Client to map these claims from
            // the standard OpenID Connect claim types (see MapStandardWebServicesFederationClaims).

            var issuer = context.Registration.ClaimsIssuer ??
                         context.Registration.ProviderName ??
                         context.Registration.Issuer.AbsoluteUri;

            context.MergedPrincipal.SetClaim(ClaimTypes.Email, issuer: issuer, value: context.Registration.ProviderType switch
            {
                // Basecamp returns the email address as a custom "email_address" node:
                ProviderTypes.Basecamp => (string?) context.UserInfoResponse?["email_address"],

                // Bitly returns one or more email addresses as a custom "emails" node:
                ProviderTypes.Bitly => context.UserInfoResponse?["emails"]
                    ?.GetUnnamedParameters()
                    ?.Where(parameter => (bool?) parameter["is_primary"] is true)
                    ?.Select(parameter => (string?) parameter["email"])
                    ?.FirstOrDefault(),

                // HubSpot returns the email address as a custom "user" node:
                ProviderTypes.HubSpot => (string?) context.UserInfoResponse?["user"],

                // Mailchimp returns the email address as a custom "login/login_email" node:
                ProviderTypes.Mailchimp => (string?) context.UserInfoResponse?["login"]?["login_email"],

                // Notion returns the email address as a custom "bot/owner/user/person/email" node
                // but requires a special capability to access this node, that may not be present:
                ProviderTypes.Notion => (string?) context.UserInfoResponse?["bot"]?["owner"]?["user"]?["person"]?["email"],

                // Patreon returns the email address as a custom "attributes/email" node:
                ProviderTypes.Patreon => (string?) context.UserInfoResponse?["attributes"]?["email"],

                // ServiceChannel and Zoho return the email address as a custom "Email" node:
                ProviderTypes.ServiceChannel or ProviderTypes.Zoho => (string?) context.UserInfoResponse?["Email"],

                // Shopify returns the email address as a custom "associated_user/email" node in token responses:
                ProviderTypes.Shopify => (string?) context.TokenResponse?["associated_user"]?["email"],

                // Yandex returns the email address as a custom "default_email" node:
                ProviderTypes.Yandex => (string?) context.UserInfoResponse?["default_email"],

                _ => context.MergedPrincipal.GetClaim(ClaimTypes.Email)
            });

            context.MergedPrincipal.SetClaim(ClaimTypes.Name, issuer: issuer, value: context.Registration.ProviderType switch
            {
                // These providers return the username as a custom "username" node:
                ProviderTypes.ArcGisOnline or ProviderTypes.Dailymotion or ProviderTypes.DeviantArt or
                ProviderTypes.Discord      or ProviderTypes.Disqus      or ProviderTypes.Kook       or
                ProviderTypes.Lichess      or ProviderTypes.Mastodon    or ProviderTypes.Mixcloud   or
                ProviderTypes.Trakt        or ProviderTypes.WordPress
                    => (string?) context.UserInfoResponse?["username"],

                // These providers don't return a username so one is created using the "first_name" and "last_name" nodes:
                ProviderTypes.Basecamp or ProviderTypes.Harvest or ProviderTypes.VkId
                    when context.UserInfoResponse?.HasParameter("first_name") is true &&
                         context.UserInfoResponse?.HasParameter("last_name")  is true
                    => $"{(string?) context.UserInfoResponse?["first_name"]} {(string?) context.UserInfoResponse?["last_name"]}",

                // BungieNet and FitBit return the username as a custom "displayName" node:
                ProviderTypes.BungieNet or ProviderTypes.Fitbit => (string?) context.UserInfoResponse?["displayName"],

                // These providers don't return a username so one is created using the "firstName" and "lastName" nodes:
                ProviderTypes.Contentful or ProviderTypes.Smartsheet
                    when context.UserInfoResponse?.HasParameter("firstName") is true &&
                         context.UserInfoResponse?.HasParameter("lastName")  is true
                    => $"{(string?) context.UserInfoResponse?["firstName"]} {(string?) context.UserInfoResponse?["lastName"]}",

                // Huawei returns the username as a custom "display_name" in the backchannel identity token:
                ProviderTypes.Huawei => context.BackchannelIdentityTokenPrincipal?.GetClaim("display_name"),

                // HubSpot returns the username as a custom "user" node:
                ProviderTypes.HubSpot => (string?) context.UserInfoResponse?["user"],

                // Mailchimp returns the username as a custom "accountname" node:
                ProviderTypes.Mailchimp => (string?) context.UserInfoResponse?["accountname"],

                // Mailchimp returns the username as a custom "sub" node:
                ProviderTypes.MusicBrainz => (string?) context.UserInfoResponse?["sub"],

                // Nextcloud returns the username as a custom "displayname" or "display-name" node:
                ProviderTypes.Nextcloud => (string?) context.UserInfoResponse?["displayname"] ??
                                           (string?) context.UserInfoResponse?["display-name"],

                // Notion returns the username as a custom "bot/owner/user/name" node but
                // requires a special capability to access this node, that may not be present:
                ProviderTypes.Notion => (string?) context.UserInfoResponse?["bot"]?["owner"]?["user"]?["name"],

                // Patreon doesn't return a username and requires using the complete user name as the username:
                ProviderTypes.Patreon => (string?) context.UserInfoResponse?["attributes"]?["full_name"],

                // ServiceChannel returns the username as a custom "UserName" node:
                ProviderTypes.ServiceChannel => (string?) context.UserInfoResponse?["UserName"],

                // Shopify doesn't return a username so one is created using the "first_name" and "last_name" nodes:
                ProviderTypes.Shopify
                    when context.TokenResponse?["associated_user"]?["first_name"] is not null &&
                         context.TokenResponse?["associated_user"]?["last_name"]  is not null
                    => $"{(string?) context.TokenResponse?["associated_user"]?["first_name"]} {(string?) context.TokenResponse?["associated_user"]?["last_name"]}",

                // These providers return the username as a custom "display_name" node:
                ProviderTypes.Spotify or ProviderTypes.StackExchange or
                ProviderTypes.Yandex  or ProviderTypes.Zoom
                    => (string?) context.UserInfoResponse?["display_name"],

                // Strava returns the username as a custom "athlete/username" node in token responses:
                ProviderTypes.Strava => (string?) context.TokenResponse?["athlete"]?["username"],

                // Streamlabs returns the username as a custom "streamlabs/display_name" node:
                ProviderTypes.Streamlabs => (string?) context.UserInfoResponse?["streamlabs"]?["display_name"],

                // Todoist returns the username as a custom "full_name" node:
                ProviderTypes.Todoist => (string?) context.UserInfoResponse?["full_name"],

                // Trovo returns the username as a custom "userName" node:
                ProviderTypes.Trovo => (string?) context.UserInfoResponse?["userName"],

                // Typeform returns the username as a custom "alias" node:
                ProviderTypes.Typeform => (string?) context.UserInfoResponse?["alias"],

                // Zoho returns the username as a custom "Display_Name" node:
                ProviderTypes.Zoho => (string?) context.UserInfoResponse?["Display_Name"],

                _ => context.MergedPrincipal.GetClaim(ClaimTypes.Name)
            });

            context.MergedPrincipal.SetClaim(ClaimTypes.NameIdentifier, issuer: issuer, value: context.Registration.ProviderType switch
            {
                // These providers return the user identifier as a custom "user_id" node:
                ProviderTypes.Amazon        or ProviderTypes.HubSpot or
                ProviderTypes.StackExchange or ProviderTypes.Typeform or
                ProviderTypes.VkId
                    => (string?) context.UserInfoResponse?["user_id"],

                // ArcGIS and Trakt don't return a user identifier and require using the username as the identifier:
                ProviderTypes.ArcGisOnline or ProviderTypes.Trakt
                    => (string?) context.UserInfoResponse?["username"],

                // Atlassian returns the user identifier as a custom "account_id" node:
                ProviderTypes.Atlassian => (string?) context.UserInfoResponse?["account_id"],

                // These providers return the user identifier as a custom "id" node:
                ProviderTypes.Airtable    or ProviderTypes.Basecamp      or ProviderTypes.Box        or
                ProviderTypes.Dailymotion or ProviderTypes.Deezer        or ProviderTypes.Discord    or
                ProviderTypes.Disqus      or ProviderTypes.Facebook      or ProviderTypes.Genesys    or
                ProviderTypes.Gitee       or ProviderTypes.GitHub        or ProviderTypes.Harvest    or
                ProviderTypes.Kook        or ProviderTypes.Kroger        or ProviderTypes.Lichess    or
                ProviderTypes.Linear      or ProviderTypes.Mastodon      or ProviderTypes.Meetup     or
                ProviderTypes.Miro        or ProviderTypes.Nextcloud     or ProviderTypes.Patreon    or
                ProviderTypes.Pipedrive   or ProviderTypes.Reddit        or ProviderTypes.Smartsheet or
                ProviderTypes.Spotify     or ProviderTypes.SubscribeStar or ProviderTypes.Todoist    or
                ProviderTypes.Twitter     or ProviderTypes.Webflow       or ProviderTypes.Weibo      or
                ProviderTypes.Yandex      or ProviderTypes.Zoom
                    => (string?) context.UserInfoResponse?["id"],

                // Bitbucket returns the user identifier as a custom "uuid" node:
                ProviderTypes.Bitbucket => (string?) context.UserInfoResponse?["uuid"],

                // Bitly returns the user identifier as a custom "login" node:
                ProviderTypes.Bitly => (string?) context.UserInfoResponse?["login"],

                // BungieNet returns the user identifier as a custom "membershipId" node:
                ProviderTypes.BungieNet => (string?) context.UserInfoResponse?["membershipId"],

                // Calendly returns the user identifier (formatted as a URI) as a custom "uri" node:
                ProviderTypes.Calendly => (string?) context.UserInfoResponse?["uri"],

                // Contentful returns the user identifier as a custom "sys/id" node:
                ProviderTypes.Contentful => (string?) context.UserInfoResponse?["sys"]?["id"],

                // DeviantArt returns the user identifier as a custom "userid" node:
                ProviderTypes.DeviantArt => (string?) context.UserInfoResponse?["userid"],

                // Fitbit returns the user identifier as a custom "encodedId" node:
                ProviderTypes.Fitbit => (string?) context.UserInfoResponse?["encodedId"],

                // Mailchimp returns the user identifier as a custom "login/login_id" node:
                ProviderTypes.Mailchimp => (string?) context.UserInfoResponse?["login"]?["login_id"],

                // Mixcloud returns the user identifier as a custom "key" node:
                ProviderTypes.Mixcloud => (string?) context.UserInfoResponse?["key"],

                // MusicBrainz returns the user identifier as a custom "metabrainz_user_id" node:
                ProviderTypes.MusicBrainz => (string?) context.UserInfoResponse?["metabrainz_user_id"],

                // Notion returns the user identifier as a custom "bot/owner/user/id" node but
                // requires a special capability to access this node, that may not be present:
                ProviderTypes.Notion => (string?) context.UserInfoResponse?["bot"]?["owner"]?["user"]?["id"],

                // ServiceChannel returns the user identifier as a custom "UserId" node:
                ProviderTypes.ServiceChannel => (string?) context.UserInfoResponse?["UserId"],

                // Shopify returns the user identifier as a custom "associated_user/id" node in token responses:
                ProviderTypes.Shopify => (string?) context.TokenResponse?["associated_user"]?["id"],

                // Strava returns the user identifier as a custom "athlete/id" node in token responses:
                ProviderTypes.Strava => (string?) context.TokenResponse?["athlete"]?["id"],

                // Stripe returns the user identifier as a custom "stripe_user_id" node in token responses:
                ProviderTypes.StripeConnect => (string?) context.TokenResponse?["stripe_user_id"],

                // Streamlabs returns the user identifier as a custom "streamlabs/id" node:
                ProviderTypes.Streamlabs => (string?) context.UserInfoResponse?["streamlabs"]?["id"],

                // Trovo returns the user identifier as a custom "userId" node:
                ProviderTypes.Trovo => (string?) context.UserInfoResponse?["userId"],

                // Tumblr doesn't return a user identifier and requires using the username as the identifier:
                ProviderTypes.Tumblr => (string?) context.UserInfoResponse?["name"],

                // Vimeo returns the user identifier as a custom "uri" node, prefixed with "/users/":
                ProviderTypes.Vimeo => (string?) context.UserInfoResponse?["uri"] is string uri &&
                    uri.StartsWith("/users/", StringComparison.Ordinal) ? uri["/users/".Length..] : null,

                // WordPress returns the user identifier as a custom "ID" node:
                ProviderTypes.WordPress => (string?) context.UserInfoResponse?["ID"],

                // Zoho returns the user identifier as a custom "ZUID" node:
                ProviderTypes.Zoho => (string?) context.UserInfoResponse?["ZUID"],

                _ => context.MergedPrincipal.GetClaim(ClaimTypes.NameIdentifier)
            });

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for validating the user-defined authentication properties.
    /// </summary>
    public sealed class ValidateChallengeProperties : IOpenIddictClientHandler<ProcessChallengeContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
                .UseSingletonHandler<ValidateChallengeProperties>()
                .SetOrder(ResolveClientRegistrationFromChallengeContext.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessChallengeContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // If no explicit shop name was attached to the challenge properties, use the
            // shop name set in the provider settings, if set. Otherwise, throw an exception.
            if (context.Registration.ProviderType is ProviderTypes.Shopify &&
              (!context.Properties.TryGetValue(Shopify.Properties.ShopName, out string? name) ||
                string.IsNullOrEmpty(name)))
            {
                var settings = context.Registration.GetShopifySettings();
                if (string.IsNullOrEmpty(settings.ShopName))
                {
                    throw new InvalidOperationException(SR.GetResourceString(SR.ID0412));
                }

                context.Properties[Shopify.Properties.ShopName] = settings.ShopName;
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the address of
    /// the authorization endpoint for the providers that require it.
    /// </summary>
    public sealed class OverrideAuthorizationEndpoint : IOpenIddictClientHandler<ProcessChallengeContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
                .UseSingletonHandler<OverrideAuthorizationEndpoint>()
                .SetOrder(AttachChallengeParameters.Descriptor.Order - 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessChallengeContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.AuthorizationEndpoint = context.Registration.ProviderType switch
            {
                // Shopify is a multitenant provider that requires setting the authorization endpoint
                // dynamically based on the shop name stored in the authentication properties.
                //
                // For more information, see
                // https://shopify.dev/docs/apps/auth/oauth/getting-started#step-3-ask-for-permission.
                ProviderTypes.Shopify when context.Properties[Shopify.Properties.ShopName] is var name =>
                    new Uri($"https://{name}.myshopify.com/admin/oauth/authorize", UriKind.Absolute),

                // Stripe uses a different authorization endpoint for express accounts.
                //
                // The type of account can be defined globally (via the Stripe options) or
                // per authentication demand by adding a specific authentication property.
                //
                // For more information, see
                // https://stripe.com/docs/connect/oauth-reference?locale=en-us#get-authorize.
                ProviderTypes.StripeConnect when context.Properties.TryGetValue(
                    StripeConnect.Properties.AccountType, out string? type) => type switch
                    {
                        StripeConnect.AccountTypes.Express  => new Uri("https://connect.stripe.com/express/oauth/authorize", UriKind.Absolute),
                        StripeConnect.AccountTypes.Standard => new Uri("https://connect.stripe.com/oauth/authorize", UriKind.Absolute),

                        _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0479))
                    },

                // Zoho requires using a region-specific authorization endpoint.
                //
                // For more information, see
                // https://www.zoho.com/accounts/protocol/oauth/multi-dc/client-authorization.html.
                ProviderTypes.Zoho when context.Properties.TryGetValue(Zoho.Properties.Location, out string? location)
                    => location?.ToUpperInvariant() switch
                    {
                        "AU" => new Uri("https://accounts.zoho.com.au/oauth/v2/auth", UriKind.Absolute),
                        "CA" => new Uri("https://accounts.zohocloud.ca/oauth/v2/auth", UriKind.Absolute),
                        "EU" => new Uri("https://accounts.zoho.eu/oauth/v2/auth", UriKind.Absolute),
                        "IN" => new Uri("https://accounts.zoho.in/oauth/v2/auth", UriKind.Absolute),
                        "JP" => new Uri("https://accounts.zoho.jp/oauth/v2/auth", UriKind.Absolute),
                        "SA" => new Uri("https://accounts.zoho.sa/oauth/v2/auth", UriKind.Absolute),
                        "UK" => new Uri("https://accounts.zoho.uk/oauth/v2/auth", UriKind.Absolute),
                        "US" => new Uri("https://accounts.zoho.com/oauth/v2/auth", UriKind.Absolute),

                        _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0477))
                    },

                _ => context.AuthorizationEndpoint
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the response mode for the providers that require it.
    /// </summary>
    public sealed class OverrideResponseMode : IOpenIddictClientHandler<ProcessChallengeContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
                .AddFilter<RequireInteractiveGrantType>()
                .UseSingletonHandler<OverrideResponseMode>()
                .SetOrder(AttachResponseMode.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessChallengeContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.ResponseMode = context.Registration.ProviderType switch
            {
                // Note: Apple requires using form_post when the "email" or "name" scopes are requested.
                ProviderTypes.Apple when context.Scopes.Contains(Scopes.Email) || context.Scopes.Contains("name")
                    => ResponseModes.FormPost,

                _ => context.ResponseMode
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the standard "scope"
    /// parameter for providers that are known to use a non-standard format.
    /// </summary>
    public sealed class FormatNonStandardScopeParameter : IOpenIddictClientHandler<ProcessChallengeContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
                .AddFilter<RequireInteractiveGrantType>()
                .UseSingletonHandler<FormatNonStandardScopeParameter>()
                .SetOrder(AttachChallengeParameters.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessChallengeContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.Request.Scope = context.Registration.ProviderType switch
            {
                // The following providers are known to use comma-separated scopes instead of
                // the standard format (that requires using a space as the scope separator):
                ProviderTypes.Deezer or ProviderTypes.Disqus  or ProviderTypes.Shopify or
                ProviderTypes.Strava or ProviderTypes.Todoist or ProviderTypes.Weibo
                    => string.Join(",", context.Scopes),

                // The following providers are known to use plus-separated scopes instead of
                // the standard format (that requires using a space as the scope separator):
                ProviderTypes.Trovo => string.Join("+", context.Scopes),

                _ => context.Request.Scope
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for persisting the state parameter in the redirect URI for
    /// providers that don't support it but allow arbitrary dynamic parameters in redirect_uri.
    /// </summary>
    public sealed class IncludeStateParameterInRedirectUri : IOpenIddictClientHandler<ProcessChallengeContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
                .AddFilter<RequireInteractiveGrantType>()
                .UseSingletonHandler<IncludeStateParameterInRedirectUri>()
                .SetOrder(FormatNonStandardScopeParameter.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessChallengeContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            if (context.RedirectUri is null)
            {
                return default;
            }

            // Note: some providers don't support the "state" parameter, don't flow
            // it correctly or don't include it in errored authorization responses.
            //
            // Since OpenIddict requires flowing the state token in every circumstance
            // (for security reasons), the state token is appended to the "redirect_uri"
            // instead of being sent as a standard OAuth 2.0 authorization request parameter.
            //
            // Note: this workaround only works for providers that allow dynamic
            // redirection URIs and implement a relaxed validation policy logic.

            if (context.Registration.ProviderType is ProviderTypes.Deezer or ProviderTypes.Huawei or ProviderTypes.Mixcloud)
            {
                context.Request.RedirectUri = OpenIddictHelpers.AddQueryStringParameter(
                    uri  : new Uri(context.RedirectUri, UriKind.Absolute),
                    name : Parameters.State,
                    value: context.Request.State).AbsoluteUri;

                context.Request.State = null;
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for attaching additional parameters
    /// to the authorization request for the providers that require it.
    /// </summary>
    public sealed class AttachAdditionalChallengeParameters : IOpenIddictClientHandler<ProcessChallengeContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessChallengeContext>()
                .AddFilter<RequireInteractiveGrantType>()
                .UseSingletonHandler<AttachAdditionalChallengeParameters>()
                .SetOrder(AttachChallengeParameters.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessChallengeContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            // Active Directory Federation Services allows sending an optional custom "resource"
            // parameter to define what API resources the access token will give access to.
            if (context.Registration.ProviderType is ProviderTypes.ActiveDirectoryFederationServices)
            {
                var settings = context.Registration.GetActiveDirectoryFederationServicesSettings();

                if (!string.IsNullOrEmpty(settings.Resource))
                {
                    context.Request.Resources = [settings.Resource];
                }
            }

            // By default, Alibaba Cloud doesn't return a refresh token for native applications but allows sending an
            // "access_type" parameter to retrieve one (but it is only returned during the first authorization dance).
            //
            // The documentation also indicates the "prompt" parameter is supported but not required
            // and can be set to "admin_consent" to force the display of the authorization page.
            else if (context.Registration.ProviderType is ProviderTypes.AlibabaCloud)
            {
                var settings = context.Registration.GetAlibabaCloudSettings();

                context.Request["access_type"] = settings.AccessType;
                context.Request.Prompt = settings.Prompt;
            }

            // Atlassian requires sending an "audience" parameter (by default, "api.atlassian.com").
            //
            // The documentation also indicates the "prompt" parameter is required, but no error is
            // returned if this parameter is not explicitly included in the authorization request.
            else if (context.Registration.ProviderType is ProviderTypes.Atlassian)
            {
                var settings = context.Registration.GetAtlassianSettings();

                context.Request.Audiences = [settings.Audience];
                context.Request.Prompt = settings.Prompt;
            }

            // FaceIt requires sending a custom "redirect_popup" parameter
            // with the value "true" for the login flow to work correctly.
            else if (context.Registration.ProviderType is ProviderTypes.FaceIt)
            {
                context.Request["redirect_popup"] = true;
            }

            // By default, Google doesn't return a refresh token but allows sending an "access_type"
            // parameter to retrieve one (but it is only returned during the first authorization dance).
            else if (context.Registration.ProviderType is ProviderTypes.Google)
            {
                var settings = context.Registration.GetGoogleSettings();

                context.Request["access_type"] = settings.AccessType;
            }

            // By default, Huawei doesn't return a refresh token but allows sending an "access_type"
            // parameter to retrieve one (but it is only returned during the first authorization dance).
            // The documentation also indicates the "display" parameter is supported but not required,
            // which can be set to "touch" to adjust the authorization page display style for mobile apps.
            else if (context.Registration.ProviderType is ProviderTypes.Huawei)
            {
                var settings = context.Registration.GetHuaweiSettings();

                context.Request["access_type"] = settings.AccessType;
                context.Request.Display = settings.Display;
            }

            // Linear allows setting the prompt parameter (setting it to "consent" will
            // force the consent screen to be displayed for each authorization request).
            else if (context.Registration.ProviderType is ProviderTypes.Linear)
            {
                var settings = context.Registration.GetLinearSettings();

                context.Request.Prompt = settings.Prompt;
            }

            // By default, MusicBrainz doesn't return a refresh token but allows sending an "access_type"
            // parameter to retrieve one (but it is only returned during the first authorization dance).
            else if (context.Registration.ProviderType is ProviderTypes.MusicBrainz)
            {
                var settings = context.Registration.GetMusicBrainzSettings();

                context.Request["access_type"] = settings.AccessType;
                context.Request["approval_prompt"] = settings.ApprovalPrompt;
            }

            // Pro Santé Connect's specification requires sending an acr_values parameter containing
            // the desired level of authentication (currently, only "eidas1" is supported). For more
            // information, see https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000045551195.
            else if (context.Registration.ProviderType is ProviderTypes.ProSantéConnect)
            {
                var settings = context.Registration.GetProSantéConnectSettings();

                context.Request.AcrValues = settings.AuthenticationLevel;
            }

            // By default, Reddit doesn't return a refresh token but
            // allows sending a "duration" parameter to retrieve one.
            else if (context.Registration.ProviderType is ProviderTypes.Reddit)
            {
                var settings = context.Registration.GetRedditSettings();

                context.Request["duration"] = settings.Duration;
            }

            // Shopify allows setting an optional access mode to enable per-user authorization.
            else if (context.Registration.ProviderType is ProviderTypes.Shopify)
            {
                var settings = context.Registration.GetShopifySettings();
                if (string.Equals(settings.AccessMode, "online", StringComparison.OrdinalIgnoreCase))
                {
                    context.Request["grant_options[]"] = "per-user";
                }
            }

            // Slack allows sending an optional "team" parameter to simplify the login process.
            else if (context.Registration.ProviderType is ProviderTypes.Slack)
            {
                var settings = context.Registration.GetSlackSettings();

                context.Request["team"] = settings.Team;
            }

            // Weibo allows sending an optional "display" parameter to adjust the authorization page
            // display style; an optional "forcelogin" parameter to force the user to log in again;
            // and an optional "language" parameter to set the language of the authorization page.
            else if (context.Registration.ProviderType is ProviderTypes.Weibo)
            {
                var settings = context.Registration.GetWeiboSettings();

                context.Request.Display = settings.Display;
                context.Request["forcelogin"] = settings.ForceLogin;
                context.Request["language"] = settings.Language;
            }

            // Yandex allows sending optional "device_id" and "device_name" parameters.
            else if (context.Registration.ProviderType is ProviderTypes.Yandex)
            {
                var settings = context.Registration.GetYandexSettings();

                if (!context.Properties.TryGetValue(Yandex.Properties.DeviceId, out string? identifier) ||
                     string.IsNullOrEmpty(identifier))
                {
                    identifier = settings.DeviceId;
                }

                if (!context.Properties.TryGetValue(Yandex.Properties.DeviceName, out string? name) ||
                     string.IsNullOrEmpty(name))
                {
                    name = settings.DeviceName;
                }

                context.Request["device_id"] = identifier;
                context.Request["device_name"] = name;
            }

            // By default, Zoho doesn't return a refresh token but
            // allows sending an "access_type" parameter to retrieve one.
            else if (context.Registration.ProviderType is ProviderTypes.Zoho)
            {
                var settings = context.Registration.GetZohoSettings();

                context.Request["access_type"] = settings.AccessType;
                context.Request.Prompt = settings.Prompt;
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for overriding the negotiated revocation
    /// endpoint client authentication method for the providers that require it.
    /// </summary>
    public sealed class OverrideRevocationEndpointClientAuthenticationMethod : IOpenIddictClientHandler<ProcessRevocationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
                .AddFilter<RequireRevocationRequest>()
                .UseSingletonHandler<OverrideRevocationEndpointClientAuthenticationMethod>()
                .SetOrder(AttachRevocationEndpointClientAuthenticationMethod.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessRevocationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            context.RevocationEndpointClientAuthenticationMethod = context.Registration.ProviderType switch
            {
                // Note: Reddit requires using basic authentication to flow the client_id for all types
                // of client applications, even when there's no client_secret assigned or attached.
                ProviderTypes.Reddit => ClientAuthenticationMethods.ClientSecretBasic,

                _ => context.RevocationEndpointClientAuthenticationMethod
            };

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for adding non-standard claims to the client
    /// assertions used for the revocation endpoint for the providers that require it.
    /// </summary>
    public sealed class AttachNonStandardRevocationClientAssertionClaims : IOpenIddictClientHandler<ProcessRevocationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
                .AddFilter<RequireClientAssertionGenerated>()
                .UseSingletonHandler<AttachNonStandardRevocationClientAssertionClaims>()
                .SetOrder(PrepareRevocationClientAssertionPrincipal.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessRevocationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.ClientAssertionPrincipal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006));

            // For client assertions to be considered valid by the Apple ID authentication service,
            // the team identifier associated with the developer account MUST be used as the issuer
            // and the static "https://appleid.apple.com" URL MUST be used as the token audience.
            //
            // For more information about the custom client authentication method implemented by Apple,
            // see https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens.
            if (context.Registration.ProviderType is ProviderTypes.Apple)
            {
                var settings = context.Registration.GetAppleSettings();

                context.ClientAssertionPrincipal.SetClaim(Claims.Private.Issuer, settings.TeamId);
                context.ClientAssertionPrincipal.SetAudiences("https://appleid.apple.com");
            }

            return default;
        }
    }

    /// <summary>
    /// Contains the logic responsible for attaching custom client credentials
    /// parameters to the revocation request for the providers that require it.
    /// </summary>
    public sealed class AttachRevocationRequestNonStandardClientCredentials : IOpenIddictClientHandler<ProcessRevocationContext>
    {
        /// <summary>
        /// Gets the default descriptor definition assigned to this handler.
        /// </summary>
        public static OpenIddictClientHandlerDescriptor Descriptor { get; }
            = OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessRevocationContext>()
                .AddFilter<RequireRevocationRequest>()
                .UseSingletonHandler<AttachRevocationRequestNonStandardClientCredentials>()
                .SetOrder(AttachRevocationRequestClientCredentials.Descriptor.Order + 500)
                .SetType(OpenIddictClientHandlerType.BuiltIn)
                .Build();

        /// <inheritdoc/>
        public ValueTask HandleAsync(ProcessRevocationContext context)
        {
            if (context is null)
            {
                throw new ArgumentNullException(nameof(context));
            }

            Debug.Assert(context.RevocationRequest is not null, SR.GetResourceString(SR.ID4008));

            // Apple implements a non-standard client authentication method for its endpoints that
            // is inspired by the standard private_key_jwt method but doesn't use the standard
            // client_assertion/client_assertion_type parameters. Instead, the client assertion
            // must be sent as a "dynamic" client secret using client_secret_post. Since the logic
            // is the same as private_key_jwt, the configuration is amended to assume Apple supports
            // private_key_jwt and an event handler is responsible for populating the client_secret
            // parameter using the client assertion once it has been generated by OpenIddict.
            if (context.Registration.ProviderType is ProviderTypes.Apple)
            {
                context.RevocationRequest.ClientSecret = context.RevocationRequest.ClientAssertion;
                context.RevocationRequest.ClientAssertion = null;
                context.RevocationRequest.ClientAssertionType = null;
            }

            return default;
        }
    }
}
